/*
*	Lightweight Isographic Scene Architecture (LISA)
*	
*	A simple, chainable interface for managing an isometric
*	scene drawn with Javascript on HTML canvas elements.
*
*	Dedicated to the memory of Lisa Marie Neff, a glowing personality
*	and loving mother, who left this world before her time. I love you and miss you dearly.
*/
( function( window , undefined ) {
	
	var
	lisa = function( selector , params ) {
		return new lisa.fn.init( selector , params );
	};
	
	//insert the requestAnimationFrame shim if needed
	var lastTime = 0;
    var vendors = ['ms', 'moz', 'webkit', 'o'];
    for(var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
        window.requestAnimationFrame = window[vendors[x]+'RequestAnimationFrame'];
        window.cancelAnimationFrame = window[vendors[x]+'CancelAnimationFrame']
		|| window[vendors[x]+'CancelRequestAnimationFrame'];
    }
 
    if (!window.requestAnimationFrame) {
        window.requestAnimationFrame = function(callback, element) {
            var currTime = new Date().getTime();
            var timeToCall = Math.max(0, 16 - (currTime - lastTime));
            var id = window.setTimeout(function() { callback(currTime + timeToCall); },
              timeToCall);
            lastTime = currTime + timeToCall;
            return id;
        };
	}
    if (!window.cancelAnimationFrame) {
        window.cancelAnimationFrame = function(id) {
            clearTimeout(id);
        };
	}
	
	lisa.fn = lisa.prototype = {
		
		constructor: lisa,
		init: function( selector , params ) {
			
			//if the selector is a string, assume it's an id attribute and get that object
			if ( typeof selector === "string" ) {
				selector = document.getElementById( selector );
			}
			
			if ( selector && selector.nodeType ) {
				
				//if there's already a lisa instance, return it
				if( selector.lisa ) return selector.lisa;
				
				//if the target doesn't have an ID give it one
				if( !selector.id ) {
					selector.id = 'lisa_stage_'+Date.now();
				}
				
				//link the element to this object
				this.context = selector;
				//link this to the element
				this.context.lisa = this;
				
				//configure the instance
				if( typeof params == 'undefined' ) params = {};
				
				var defaultParams = {
					tileSize: {
						x: 96,
						y: 48,
						z: 24
					},
					overlay: true, //display overlay textures on the terrain
					shadows: true, //display shadows
					particles: true, //display particles,
					particleDensity: 1, //density of the particle effects as a floating point percentage
					objects: true, //display object entities
					sprites: true, //display sprite entities
					
					maxViewableRangeZ: 10, //sets the maximum viewable range on the Z axis, this is used when calculating the z Index of the terrain to display. X and Y ranges are automatically calculated so that they fill the window
					
					occlusion: true, //toggle lisa's occlusion system (suggested to set this to true)
					useCameraForOcclusion: true, //uses the camera position as the occlusion point
				}
				
				params = lisa.extend( defaultParams , params );
				
				if( !params.width || !params.height ) throw new Error('You must specify a height and width to instance LISA on an object.');
				
				this.params = params;
				
				//set up the canvas and add it to the parent element
				this.canvas = document.createElement('canvas');
				this.canvas.id = this.context.id + '_scene';
				this.canvas.width = params.width;
				this.canvas.height = params.height;
				this.context.appendChild( this.canvas );
				
				//bind the blur/focus handlers
				var obj = this; //make a local reference to this instance for use in the enclosure
				document.addEventListener('blur',function(){
					obj.trigger('onWindowBlur');
				});
				document.addEventListener('focus',function(){
					obj.trigger('onWindowFocus');
				});
				
				//lastly let's calculate visibility once just to make sure it's up to date
				this._calculateVisibility();
				
				return this;
			}
			
			throw new Error('You must specify a DOMElement or a String id attribute of a DOMElement to instance LISA');
			
		},
		//holds the version of lisa
		version: '0.1',
		
		//object to hold various flag information
		flags: {
			rendering: false, //should lisa be drawing frames
		},
		
		timers: {}, //holds timer references
		
		terrain: {}, //holds terrain data
		
		renderBuffer: {}, //holds all rendered entities
		
		materials: {}, //holds material information
		
		particles: {}, //holds particle effect entities
		
		textures: {}, //holds texture information
		
		sounds: {}, //holds sound effects
		
		camera: {
			//stores the camera's current position
			position: {
				x:0,
				y:0,
				z:0,
				zoom: 1
			},
			
			//store the final destination of the camera's current movement track
			destination: {
				x:null,


				y:null,
				z:null,
				zoom:null
			},
			
			//store the starting location for this section of the camera animation
			origin: {
				x:null,
				y:null,
				z:null,
				zoom:null
			},
			
			//stores the movement vectors for the camera if it's being animated
			vector: {
				x:null,
				y:null,
				z:null,
				zoom:null
			},
			
			//holds an array of objects with x, y, z, zoom data to use in creating a camera track. populated by the setCameraTrack() function
			track:[],
			
			//holds the callback function to execute at the end of the camera animation
			callback: null,
			
			//holds the time of the last render of the camera position during animation, or null otherwise
			lastRender:null,
			
			//TODO - decide whether to remove this or make it more flexible
			//tells LISA to draw a crosshair at the center of the screen (for debug purposes, will likely be removed before release)
			showCrosshair: true
		},
		
		//holds the occlusion data for LISA
		occlusion: {
			point: {
				x:0,
				y:0,
				z:0
			},
			range: {}
		},
		
		//holds visibility information about this instance
		visibility: {
			x:[0,0],
			y:[0,0],
			z:[0,0],
			offsets: {
				x:0,
				y:0,
				z:0
			}
		},
		
		stats: { //holds statistical data about this instance
			totalFrames:0,
			totalRenderTime:0.0,
			
			totalTextures:0,
			loadedTextures:0,
		},
		
		//triggers an event with optional data
		trigger: function( evt , data ) {
			
			if( typeof data == 'undefined' ) data = {};
			
			if( typeof this.callbacks[evt] != 'undefined' ) {
				
				for( var i in this.callbacks[evt] ) {
					this.callbacks[evt][i]( this , data );
				}
				
			}
			
		},
		
		//sets a single parameter
		setParam: function( param , value ) {
			if( typeof value == 'object' ) {
				this.params[param] = this.extend( this.params[param] , value );
			} else {
				this.params[param] = value;
			}
			
			return this;
		},
		
		//sets multiple parameters at once
		setParams: function( params ) {
			
			this.params = this.extend( this.params , params );
			
			return this;
			
		},
		
		//sets an array of tiles to be occluded by LISA
		/*
			occlusion range specified as an array of objects:
			[
				{x:0,y:0,z:0},
				{x:1,y:1,z:1}
			]
		*/
		setOccludedTiles: function( tiles ) {
			
			for( var i = 0; i < tiles.length; ++i ) {
				
				if( !this.occlusion.range[ tiles[i].y ] ) this.occlusion.range[ tiles[i].y ] = {};
				if( !this.occlusion.range[ tiles[i].y ][ tiles[i].z ] ) this.occlusion.range[ tiles[i].y ][ tiles[i].z ] = {};
				this.occlusion.range[ tiles[i].y ][ tiles[i].z ][ tiles[i].x ] = true;
				
			}
			
			return this;
			
		},
		
		//clears the specified tiles from the occlusion system or all of them if called with no argument passed
		clearOccludedTiles: function( tiles ) {
			
			if( typeof tiles == 'undefined' ) {
				this.occlusion.range = [];
			} else {
				
				for( var i = 0; i < tiles.length; ++i ) {
					
					if( !this.occlusion.range[ tiles[i].y ] ) this.occlusion.range[ tiles[i].y ] = {};
					if( !this.occlusion.range[ tiles[i].y ][ tiles[i].z ] ) this.occlusion.range[ tiles[i].y ][ tiles[i].z ] = {};
					delete this.occlusion.range[ tiles[i].y ][ tiles[i].z ][ tiles[i].x ];
					//cleanup z index
					if( this.occlusion.range[ tiles[i].y ][ tiles[i].z ].length == 0 ) {
						delete this.occlusion.range[ tiles[i].y ][ tiles[i].z ];
					}
					//cleanup y index
					if( this.occlusion.range[ tiles[i].y ].length == 0 ) {
						delete this.occlusion.range[ tiles[i].y ];
					}
					
				}
				
			}
			
			return this;
			
		},
		
		//sets the occlusion point when the camera is not being used for the occlusion point
		setOcclusionPoint: function( point ) {
			if( !this.params.useCameraForOcclusion ) {
				this.occlusion.point = point;
				this.trigger('onOcclusionPointChange');
			}
			return this;
		},
		
		//sets a camera path track for more complex camera transitions, accepts a javascript array of { x,y,z,zoom,time,delay } objects
		setCameraTrack: function( track , callback ) {
			
			//pop off the first track element
			var el = track.shift();
			
			this.camera.track = track;
			
			if( callback ) this.camera.callback = callback;

			this.setCamera( el );
			
			return this;
			
		},
		
		//sets the camera position and optionally transitions to it over a period of time
		setCamera: function( options , callback ) {
			
			if( typeof options.time != 'undefined' ) {
				//transitioning to this position, set the info and let the frame renderer do the work
				this.camera.vector = {
					x: typeof options.x != 'undefined' && options.x != this.camera.position.x ? ( options.x - this.camera.position.x ) / options.time : null,
					y: typeof options.y != 'undefined' && options.y != this.camera.position.y ? ( options.y - this.camera.position.y ) / options.time : null,
					z: typeof options.z != 'undefined' && options.z != this.camera.position.z ? ( options.z - this.camera.position.z ) / options.time : null,
					zoom: typeof options.zoom != 'undefined' && options.zoom != this.camera.position.zoom ? ( options.zoom - this.camera.position.zoom ) / options.time : null
				}
				this.camera.destination = {
					x: typeof options.x != 'undefined' ? options.x : null,
					y: typeof options.y != 'undefined' ? options.y : null,
					z: typeof options.z != 'undefined' ? options.z : null,
					zoom: typeof options.zoom != 'undefined' ? options.zoom : null
				}
				this.camera.origin = {
					x: this.camera.position.x,
					y: this.camera.position.y,
					z: this.camera.position.z,
					zoom: this.camera.position.zoom
				}
				
				//set the last render time to now so the camera position function animates it each frame
				this.camera.lastRender = Date.now();
				
				//if there's a delay on this frame, delay by that many ms
				if( options.delay ) this.camera.lastRender += options.delay;
				
				//if we got a callback, set it
				if( callback ) this.camera.callback = callback;
				
			} else {
				//no transition time, just update the position
				if( typeof options.x != 'undefined' ) this.camera.position.x = options.x;
				if( typeof options.y != 'undefined' ) this.camera.position.y = options.y;
				if( typeof options.z != 'undefined' ) this.camera.position.z = options.z;
				if( typeof options.zoom != 'undefined' ) this.camera.position.zoom = options.zoom;
				//turn off rendering since we're jumping to a position
				this.camera.lastRender = null;
				//clear the callback function if there was one since you can't have a callback with no transition time
				this.camera.callback = null;
				//clear the camera track since there shouldn't be one if we're using a non-animated camera position
				this.camera.track = [];
				
				//if we're using the camera for occlusion, make sure to update occlusion too since we won't be animating the position for the system to pick up the change
				if( this.params.useCameraForOcclusion ) {
					this.occlusion.point = this.camera.position;
					this.trigger('onOcclusionPointChange');
				}
				
			}
			
			return this;
			
		},
		
		//loads textures and their configuration information into the system
		loadTextures: function( data ) {
			
			for( var i = 0; i < data.length; ++i ) {
				
				//if the texture is already loaded, skip it
				if( this.textures[ data[i].id ] ) continue;
				
				var img = document.createElement('img');
				img.onload = function() {
					++this.lisa.stats.loadedTextures;
					this.loaded = true;
				}
				img._id = data[i].id;
				img.lisa = this;
				img.loaded = false;
				//load the image
				img.src = data[i].img;
				//save the actual image object as the image
				data[i].img = img;
				//push the object into the textures array
				this.textures[ data[i].id ] = data[i];
				
				//increment total textures counter
				++this.stats.totalTextures;
			}
			
			return this;
			
		},
		
		//loads material data from a JSON object into the system
		loadMaterials: function( data ) {
			
			for( var i in data ) {
				//assign the material reference for the tex
				if( !this.textures[ data[i].tex ] ) throw new Error('Material ('+data[i].id+') loaded and texture ('+data[i].tex+') is not loaded! Make sure to load your texture dependencies before loading any materials that use them.' );
				//assign the texture reference
				data[i].tex = this.textures[ data[i].tex ];
				//save the material
				this.materials[ data[i].id ] = data[i];
			}
			
			return this;
			
		},
		
		//loads particle effect data from a JSON object into the system
		loadParticles: function( data ) {
			
			for( var i in data ) {
				this.particles[ data[i].id ] = data[i];
			}
			
			return this;
			
		},
		
		//loads a chunk of terrain data from a JSON object into the system
		loadTiles: function( data ) {
			
			var mats = {};
			
			for( var i in data ) {
				
				//if there's no index for this y axis, make one
				if( !this.terrain[ data[i].y ] ) this.terrain[ data[i].y ] = {};
				//if there's no index for this y and z axis combo, make one
				if( !this.terrain[ data[i].y ][ data[i].z ] ) this.terrain[ data[i].y ][ data[i].z ] = {};
				//lastly, save this piece of terrain data at the location of its y, z, and x axis
				this.terrain[ data[i].y ][ data[i].z ][ data[i].x ] = data[i].d;
				
			}
			
			return this;
			
		},
		
		//starts the frame renderer
		startRendering: function() {

			//flag lisa as animating
			this.flags.isRendering = true;
			//local reference for the callback functions to use
			var obj = this;
			//start rendering
			window.requestAnimationFrame( function( frameStart) {
				obj._renderFrame( frameStart );
			} , this.canvas );
			
			//calculate rendering stats every second
			this.timers.frameRendererStats = setInterval( function() {
				obj._renderFrameStats();
			} , 1000 );
			
			return this;
			
		},
		
		//stops the frame renderer
		stopRendering: function() {
			
			//flag lisa to stop rendering
			this.flags.isRendering = true;
			if( this.timers.frameRendererStats ) clearInterval( this.timers.frameRendererStats );
			
			return this;
			
		},
		
		//calculates the stats for the frame renderer (runs once a second)
		_renderFrameStats: function() {
			
			this.stats.currentFPS = this.stats.totalFrames;
			this.stats.renderTimePerFrame = this.stats.totalFrames > 0 ? this.stats.totalRenderTime / this.stats.totalFrames : 0;
			this.stats.maxFPS = Math.round( 1000 / this.stats.renderTimePerFrame );
			this.stats.totalFrames = 0;
			this.stats.totalRenderTime = 0.0;
			
			this.trigger( 'onStatsUpdate' );
			
		},
		
		//calculates the camera position if needed
		_updateCameraPosition: function( frameStart ) {
			
			if( this.camera.lastRender && this.camera.lastRender < Date.now() ) {
				
				var done = true;
				
				var camera = this.camera;
				
				var renderMS = frameStart - camera.lastRender;
				if( renderMS <= 0 ) return;
				
				//update the camera location
				if( camera.vector.x != null ) camera.position.x += camera.vector.x * renderMS;
				if( camera.vector.y != null ) camera.position.y += camera.vector.y * renderMS;
				if( camera.vector.z != null ) camera.position.z += camera.vector.z * renderMS;
				if( camera.vector.zoom != null ) camera.position.zoom += camera.vector.zoom * renderMS;
				
				//sanity checks. If we're done animating any of these vectors make sure we set them to the correct final position and then set the vector to null
				if( 
					camera.vector.x != null &&
					( 
						( camera.vector.x > 0 && camera.position.x >= camera.destination.x ) ||
						( camera.vector.x < 0 && camera.position.x <= camera.destination.x )
					)
				) {
					camera.vector.x = null;
					camera.position.x = camera.destination.x;
					camera.destination.x = null;
				} else if( camera.vector.x != null ) {
					done = false;
				}
				
				if( 
					camera.vector.y != null &&
					( 
						( camera.vector.y > 0 && camera.position.y >= camera.destination.y ) ||
						( camera.vector.y < 0 && camera.position.y <= camera.destination.y )
					)
				) {
					camera.vector.y = null;
					camera.position.y = camera.destination.y;
					camera.destination.y = null;
				} else if( camera.vector.y != null ) {
					done = false;
				}
				
				if( 
					camera.vector.z != null &&
					( 
						( camera.vector.z > 0 && camera.position.z >= camera.destination.z ) ||
						( camera.vector.z < 0 && camera.position.z <= camera.destination.z )
					)
				) {
					camera.vector.z = null;
					camera.position.z = camera.destination.z;
					camera.destination.z = null;
				} else if( camera.vector.z != null ) {
					done = false;
				}
				
				if( 
					camera.vector.zoom != null &&
					( 
						( camera.vector.zoom > 0 && camera.position.zoom >= camera.destination.zoom ) ||
						( camera.vector.zoom < 0 && camera.position.zoom <= camera.destination.zoom )
					)
				) {
					camera.vector.zoom = null;
					camera.position.zoom = camera.destination.zoom;
					camera.destination.zoom = null;
				} else if( camera.vector.zoom != null ) {
					done = false;
				}
				
				//if we're done animating all of the vectors, set the lastRender to null to stop animating the camera every frame, otherwise log this frame as the last render and continue
				if( done ) {
					//done with the current vectors, see if we have another position to animate to from the camera track
					if( camera.track.length ) {
						
						//pop the next track position out of the track array
						var el = camera.track.shift();
						
						//start the animation to the next point
						this.setCamera( el );
						
					} else {
						//done animating everything in the track, set the lastRender to null to stop calculating the camera every frame
						camera.lastRender = null;
						
						//if we have a callback function, schedule it
						if( camera.callback ) {
							
							//make a local copy of the function
							var cbFunc = camera.callback;
							//nullify the camera reference
							camera.callback = null;
							
							//schedule the callback
							setTimeout( function() { cbFunc() } , 0 );
							
						}
						
					}
				} else {
					//not done with the current vectors, keep animating them
					camera.lastRender = frameStart;
				}
				
				if( this.params.useCameraForOcclusion ) {
					this.occlusion.point = this.camera.position;
					this.trigger('onOcclusionPointChange');
				}
				
			} //end if
			
		},
		
		//internal function to determine the visible tile range of the game display
		_calculateVisibility: function() {
			
			//local reference to tile size
			var tileSize = this.params.tileSize;
			var camera = this.camera;
			var windowSize = {
				x: parseInt(this.canvas.width) / ( camera.position.zoom * tileSize.x ),
				y: parseInt(this.canvas.height) / ( camera.position.zoom * tileSize.y ),
				z: parseInt(this.canvas.height) / ( camera.position.zoom * tileSize.z )
			}
			
			//initialize the x offset
			var xO = 0;
			//get the rounded Y value
			var yR = Math.round(camera.position.y);
			//get the difference between the rounded Y value and the actual Y value
			var yD = yR - camera.position.y;
			
			//if the y axis is changing we need to do some more complicated math so that we slowly add or subtract the indentation to keep the transition smooth instead of calculating it every frame based on the current Y position (caused zig zagging and speed illusion issues)
			if( camera.vector.y ) {
				//camera is animating the Y axis, check to see if the source and destination modulus matches
				var sM = Math.round( camera.origin.y ) % 2;
				var dM = Math.round( camera.destination.y ) % 2;
				if( sM == dM ) {
					//we're not changing indentation levels, just set the offset to the appropriate one
					if( sM ) xO = 0.5;
				} else {
					//calculate the indent based on how far along we are in the Y transition
					xO = (camera.position.y - camera.origin.y) / ( camera.destination.y - camera.origin.y ) * 0.5;
					//changing indentation levels, see if we're inverting the offset by checking to see if the source was the tile that had it
					if( sM ) {
						//removing the indent, invert it
						xO = 0.5 - xO;
					}
				}
			} else {
				//camera is not animating the Y axis, just calculate the correct amount to add based on the current camera position
				xO = Math.abs(yD) * 0.5;
				if( yR % 2 ) {
					//odd row, indented
					if( yD <= 0 ) {
						xO = 0.5 - xO;
					}
				}
			}
			
			//calculate visible range and return it
			this.visibility = {
				x:this._makeRangeArray(
					Math.ceil(camera.position.x - ( windowSize.x / 2 ))-1,
					Math.ceil(camera.position.x + ( windowSize.x / 2 ))+1
				),
				y:this._makeRangeArray(
					Math.ceil(camera.position.y - ( windowSize.y / 2 ))-1,
					Math.ceil(camera.position.y + ( windowSize.y / 2 ))+1
				),
				z:this._makeRangeArray(
					Math.ceil(camera.position.z - this.params.maxViewableRangeZ),
					Math.ceil(camera.position.z + this.params.maxViewableRangeZ)
				),
				offsets: {
					x: camera.position.x + xO + 0.5,
					y: camera.position.y + 0.5,
					z: camera.position.z - 0.25
				}
			}
			
		},
		
		//internal: frame rendering function, called to render each frame of the canvas
		_renderFrame: function( frameStart ) {
			
			//clear frame rendering stat counts
			this.stats.totalRenderedTiles = 0;
			
			//get the start time of this frame
			//var frameStart = Date.now();
			
			//trigger the start of frame rendering code
			this.trigger('onBeginFrameRender',{frameStart:frameStart});
			
			//update the camera position if needed
			this._updateCameraPosition( frameStart );
			
			//calculate the visible tiles we need to render
			this._calculateVisibility();
			
			var visibility = this.visibility;
			
			var ctx = this.canvas.getContext('2d');
			
			//get drawing context
			ctx.clearRect( 0 , 0 , this.canvas.width , this.canvas.height );
			
			//get the combined indexes from both the renderBuffer and the terrain system
			var yA = this._intersect( visibility.y , [].concat( Object.keys( this.terrain ) , Object.keys( this.renderBuffer ) ).sort(function(a,b){return a-b}) );
			
			//iterate the visible tiles and see if we have anything to render
			for( var yI in yA ) {
				
				var y = yA[yI];
				
				var zA = [];
				if( this.terrain[ y ] ) zA = zA.concat( Object.keys( this.terrain[ y ] ) );
				if( this.renderBuffer[ y ] ) zA = zA,concat( Object.keys( this.renderBuffer[ y ] ) );
				zA = this._intersect( visibility.z , zA.sort(function(a,b){return a-b}) );
				for( var zI in zA ) {
					
					var z = zA[zI];
					
					var xA = [];
					if( this.terrain[ y ] && this.terrain[ y ][ z ] ) xA = xA.concat( Object.keys( this.terrain[ y ][ z ] ) );
					if( this.renderBuffer[ y ] && this.renderBuffer[ y ][ z ] ) xA = xA.concat( Object.keys( this.renderBuffer[ y ][ z ] ) );
					
					xA = this._intersect( visibility.x , xA.sort(function(a,b){return a-b}) );
					
					for( xI in xA ) {
						
						var x = xA[xI];
						
						//if this tile is not occluded, draw it
						this._drawTile( x , y , z , ctx );
						
					}//end x loop
					
				}//end z loop
				
			}//end y loop
			
			//draw the camera crosshair if needed
			if( this.camera.showCrosshair ) {
				
				ctx.save();
				ctx.fillStyle = '#FF0000';
				ctx.fillRect( this.canvas.width / 2 - 5 , this.canvas.height / 2 - 1 , 10 , 2 );
				ctx.fillRect( this.canvas.width / 2 - 1 , this.canvas.height / 2 - 5 , 2 , 10 );
				ctx.restore();
				
			}
			
			//how long did it take to render this frame
			var frameRenderTime = Date.now() - frameStart;
			
			//trigger the end of frame rendering code
			this.trigger('onEndFrameRender',{frameStart:frameStart,frameRenderTime:frameRenderTime});
			
			//calculate the next frame delay and schedule it
			var obj = this;
			
			//save render time stats
			++this.stats.totalFrames;
			this.stats.totalRenderTime += frameRenderTime;
			
			//schedule the next frame
			window.requestAnimationFrame( function( frameStart ) {
				obj._renderFrame( frameStart );
			} , this.canvas );
			
		},
		
		
		//draws a tile of content onto a canvas context
		_drawTile: function( x , y , z , ctx ) {
			
			if( this.occlusion.range[ y ] 
			&& this.occlusion.range[ y ][ z ]
			&& this.occlusion.range[ y ][ z ][ x ] ) return;
			
			//local reference to the buffer
			var renderBuffer = this.renderBuffer;
			
			var terrain = this.terrain;
			
			var tileSize = this.params.tileSize;
			
			var camera = this.camera.position;
			
			var zoom = camera.zoom;
			
			var visibility = this.visibility;
			
			//check to see if there's terrain to render
			if( terrain[y] && terrain[y][z] && terrain[y][z][x] ) {
				
				++this.stats.totalRenderedTiles;
				
				var tile = terrain[y][z][x];
				var texture = this.materials[ tile.mat ].tex;
				//render the terrain for this tile
				var xpos =
				//calculate the 3d x position into 2d x position based on the camera offset
				(
					( tileSize.x * zoom )
					* (x - visibility.offsets.x)
				)
				//add half the width to the 2d coordinate since they're based on the center point and some are negative
				+ ( this.params.width / 2 )
				//add the indent on all odd numbered rows to create the tiling effect
				+ (
					y % 2
					* ( tileSize.x * zoom ) / 2
				)
				//add any offset for the texture configuration
				+ ( texture.dx * zoom );
				
				var ypos =
				//translate the 3d y position into 2d y based on the camera offset
				(
					( tileSize.y + tileSize.z ) * zoom
					/ 2
					* (y - visibility.offsets.y)
				)
				//add half the height to the 2d coordinate since they're based on the center point and some are negative
				+ ( this.params.height / 2 )
				//subtract the z index height difference relative to the camera offset
				- (
					( tileSize.z * zoom )
					* ( z - visibility.offsets.z )
				)
				//add any offset for the texture configuration
				+ ( texture.dy * zoom );
				
				ctx.drawImage( texture.img , texture.sx , texture.sy , texture.sw , texture.sh , xpos , ypos , texture.dw * zoom , texture.dh * zoom );
				
			}
			
		},
		
		
		bind: function( event , callback ) {
			
			if( !this.callbacks[ event ] ) this.callbacks[ event ] = [];
			this.callbacks[ event ].push( callback );
			
			return this;
			
		},
		
		
		/* holds callback functions */
		callbacks: {
		},
		
		
		
		
		
		
		/* utility functions */
		
		//deletes all references to a chunk of terrain from the indexes
		deleteTerrain: function( obj ) {
			if( typeof obj.x != 'undefined' ) {
				//this was an individual object
				var xyz = obj.x + ':' + obj.y + ':' + obj.z;
				
				//nullify the data first to make sure memory frees
				this.terrain.g[ xyz ] = null;
				this.terrain.x[ obj.x ][ xyz ] = null;
				this.terrain.y[ obj.y ][ xyz ] = null;
				this.terrain.z[ obj.z ][ xyz ] = null;
				
				delete this.terrain.g[ xyz ];
				delete this.terrain.x[ obj.x ][ xyz ];
				delete this.terrain.y[ obj.y ][ xyz ];
				delete this.terrain.z[ obj.z ][ xyz ];
			} else {
				//this was an array of objects, loop them and delete them
				for( var i in obj ) {
					this.deleteTerrain( obj[i] );
				}
			}
		},
		
		//cleans up terrain outside the boundaries
		cleanupTerrain: function( obj ) {
			
			//cleanup x if set
			if( obj.x ) {
				for( var x in this.terrain.x ) {
					if( obj.x.h && x > obj.x.h ) {
						this.deleteTerrain( this.terrain.x[x] );
						continue;
					}
					if( obj.x.l && x < obj.x.l ) {
						this.deleteTerrain( this.terrain.x[x] );
						continue;
					}
				}
			}
			if( obj.y ) {
				for( var y in this.terrain.y ) {
					if( obj.y.h && y > obj.y.h ) {
						this.deleteTerrain( this.terrain.y[y] );
						continue;
					}
					if( obj.y.l && y < obj.y.l ) {
						this.deleteTerrain( this.terrain.y[y] );
						continue;
					}
				}
			}
			if( obj.z ) {
				for( var z in this.terrain.z ) {
					if( obj.z.h && z > obj.z.h ) {
						this.deleteTerrain( this.terrain.z[z] );
						continue;
					}
					if( obj.z.l && z < obj.z.l ) {
						this.deleteTerrain( this.terrain.z[z] );
						continue;
					}
				}
			}
			
			return this.terrain;
			
		},
		
		//props to Pavel Podlipensky @ StackOverflow for this quick little snippet to make an array of numbers: http://stackoverflow.com/questions/8069315/create-array-of-all-integers-between-two-numbers-inclusive-in-javascript-jquer
		_makeRangeArray: function( low , high ) {
			var arr = [];
			//local low var to not fuck up the original
			var llow = low;
			while( llow < high ) {
				arr.push( llow++ );
			}
			return arr;
		},
		
		//fucking kudos to atk on stackOverflow for this beauty: http://stackoverflow.com/questions/1885557/simplest-code-for-array-intersection-in-javascript
		_intersect: function(a, b) {
		  var ai=0, bi=0;
		  var result = new Array();
		
		  while( ai < a.length && bi < b.length ) {
			 if      (a[ai] < b[bi] ){ ai++; }
			 else if (a[ai] > b[bi] ){ bi++; }
			 else /* they're equal */
			 {
			   result.push(a[ai]);
			   ai++;
			   bi++;
			 }
		  }
		
		  return result;
		},
		
		_parseJSON: function( data ) {
			if ( !data || typeof data !== "string") {
				return null;
			}
	
			// Make sure leading/trailing whitespace is removed (IE can't handle it)
			data = lisa.trim( data );
	
			// Attempt to parse using the native JSON parser first
			if ( window.JSON && window.JSON.parse ) {
				return window.JSON.parse( data );
			}
	
			// Make sure the incoming data is actual JSON
			// Logic borrowed from http://json.org/json2.js
			if ( rvalidchars.test( data.replace( rvalidescape, "@" )
				.replace( rvalidtokens, "]" )
				.replace( rvalidbraces, "")) ) {
	
				return ( new Function( "return " + data ) )();
	
			}
		},
		
		trim: String.prototype.trim && !String.prototype.trim.call("\uFEFF\xA0") ?
			function( text ) {
				return text == null ?
					"" :
					String.prototype.trim.call( text );
			} :
	
			// Otherwise use our own trimming functionality
			function( text ) {
				return text == null ?
					"" :
					( text + "" ).replace( /^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, "" );
			},
		
	}
	
	// Give the init function the prototype for later instantiation
	lisa.fn.init.prototype = lisa.fn;
	
	lisa.extend = function( objA , objB ) {
		
		var obj = {};
		
		for( var i in objA ) {
			obj[i] = typeof objA[i] == 'object' ? lisa.extend( {} , objA[i] ) : objA[i];
		}
		for( var i in objB ) {
			obj[i] = typeof objB[i] == 'object' ? lisa.extend( typeof obj[i] == 'undefined' ? {} : obj[i] , objB[i] ) : objB[i];
		}
		
		return obj;
		
	}
	
	//expose LISA to the window
	window.lisa = window.L = lisa;
	
} ) ( window )